import json
import os
import sys
import time
import traceback
from typing import Callable

import pyautogui
from loguru import logger
from requests_html import HTMLSession, AsyncHTMLSession
from ruamel import yaml
from selenium import webdriver
from selenium.common.exceptions import (ElementClickInterceptedException,
                                        TimeoutException, WebDriverException)
from selenium.webdriver import ActionChains
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import DEFAULT_EXECUTABLE_PATH
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
from sqlalchemy import create_engine
from sqlalchemy.exc import SQLAlchemyError, ResourceClosedError, OperationalError

from conf.config import CONFIG_YML


class TimeIt:
    def __init__(self, func):
        self.func = func

    def __get__(self, instance, owner):
        start = time.perf_counter()
        self.func(instance)
        end = time.perf_counter()
        time_consuming = end - start
        ML.info("Program: '%s' execution time is: %.2f second" % (owner.__name__, time_consuming))
        return


class PlausibleError(Exception):
    def __init__(self, error_type):
        self.type = error_type


class Loggings:
    __instance = None

    def __new__(cls, *args, **kwargs):
        if not cls.__instance:
            cls.__instance = super(Loggings, cls).__new__(cls, *args, **kwargs)

        return cls.__instance

    @staticmethod
    def init_logger():
        return logger


ML = Loggings().init_logger()
ML.add("DrivingRecord/steps_logs/steps.log", rotation="20 min", retention="2 h")


class BaseSpider:
    __configPath = 'DrivingRecord/config/config.yml'
    sleep: Callable[[float], None]

    def __init__(self):
        self.sleep = time.sleep

        if not os.path.exists(self.__configPath):
            os.makedirs('DrivingRecord/config')
            self.make_config(self.__configPath, CONFIG_YML)
            # ML.info('初始化完成，请重启软件...')
            sys.exit(0)

        self.ym = self.read_config(self.__configPath)
        if self.get_settings_field('owner') == CONFIG_YML["owner"]:
            ML.warning("更改 ‘%s’文件 <owner> 字段" % self.__configPath)
            self.sleep(3)
            sys.exit(1)

        # self.base = declarative_base()
        self.db = None
        self.db_engine = None
        self.conn = None
        self.session = None
        self.driver = None
        self.driver_path = self.get_settings_field('executable_path', True) or DEFAULT_EXECUTABLE_PATH
        self.job_name = None

    # 如果relaxed为True, 则当执行指定callback()抛出错误时捕捉错误并改为抛出PlausibleError，供用户函数捕捉处理
    def plausible_to_err(self, relaxed, callback, error_type=None):
        try:
            return callback()
        except Exception as e:
            if relaxed:
                print('This error is considered plausible, will ignore it!')
                raise PlausibleError(error_type)
            else:
                raise e

    def _init_web_driver(self):
        if self.driver is None:
            chrome_options = Options()
            chrome_options.page_load_strategy = 'normal'
            if self.get_settings_field('driverDebug') is False:
                chrome_options.add_argument("--headless")
            chrome_options.add_argument("--no-sandbox")
            chrome_options.add_argument("--disable-setuid-sandbox")  # 仅限Linux）
            chrome_options.add_argument(self.get_settings_field('windowSize'))
            # chrome_options.add_argument('--start-maximized')
            # 禁用日志输出，在无头模式下
            chrome_options.add_experimental_option('excludeSwitches', ['enable-logging'])
            chrome_options.add_argument('log-level=3')
            # chrome_options.add_argument('--disable-dev-shm-usage')
            # chrome_options.add_argument('blink-settings=imagesEnabled=false')
            chrome_options.add_argument('--disable-gpu')
            chrome_options.add_argument('--disable-prompt-on-repost')
            chrome_options.add_argument('--disable-component-update')
            chrome_options.add_argument('--safebrowsing-disable-auto-update')
            chrome_options.add_argument('--safebrowsing-disable-download-protection')
            chrome_options.add_argument('--disable-background-timer-throttling')
            chrome_options.add_argument('--disable-backgrounding-occluded-windows')
            chrome_options.add_argument('--disable-renderer-backgrounding')
            chrome_options.add_argument('-ignore-certificate-errors')
            chrome_options.add_argument('-ignore -ssl-errors')
            chrome_options.add_experimental_option('useAutomationExtension', False)
            chrome_options.add_experimental_option('excludeSwitches', ['enable-automation'])

            # dc = DesiredCapabilities.CHROME
            # dc['goog:loggingPrefs'] = {'browser': 'ALL'}
            # dc['acceptInsecureCerts'] = True
            self.driver = webdriver.Chrome(options=chrome_options, executable_path=self.driver_path)
            self.driver.set_page_load_timeout(60)
            self.driver.set_script_timeout(60)
            self.driver.implicitly_wait(5)

    # Additional steps needed after initializing webdriver, before the tasks can be executed
    def setup(self):
        pass

    def dispose(self):
        if self.driver:
            self.driver.quit()

    def exit(self, status: int):
        ML.info('Program End Status: %d' % status)
        # sys.exit(status)
        self.dispose()
        os._exit(status)

    def log_web_error_stack(self, e):
        if str(e).startswith('Message: chrome not reachable'):
            ML.debug('Browser closed, exiting...')
        else:
            ML.error(e.args[0])
            traceback.print_stack(file=open("DrivingRecord/steps_logs/error.log", "w+", encoding='utf-8'))
            self.exec_driver_action(self.driver, 'get_screenshot_as_file', 'DrivingRecord/steps_logs/ES.png')
            raise e

    def _exec_driver_action(self, obj, action, *args, **kwargs):
        if self.driver is None:
            self._init_web_driver()
            self.setup()
            obj = self.driver

        try:
            return getattr(obj, action)(*args, **kwargs)
        except ElementClickInterceptedException as e:
            if str(e).startswith('Message: element click intercepted'):
                self._exec_driver_action(self.driver, 'execute_script', "arguments[0].click();", obj)
                # self.driver.execute_script("arguments[0].click();", obj)
            else:
                raise e

        except WebDriverException as e:
            self.log_web_error_stack(e)

    def exec_driver_action(self, obj, action, *args, **kwargs):
        return self._exec_driver_action(obj, action, *args, **kwargs)

    # Each element of actions is a list of (action_name, args_list, kwargs_dict)
    def exec_driver_action_chain_nowait(self, actions):
        if self.driver is None:
            self._init_web_driver()
            self.setup()
            # obj = self.driver

        while True:
            self.sleep(0.2)
            try:
                ac = ActionChains(self.driver)
                for action in actions:
                    if len(action) == 1:
                        action.append([])
                    if len(action) == 2:
                        action.append({})

                    getattr(ac, action[0])(*action[1], **action[2])

                ac.perform()

            except WebDriverException as e:
                self.log_web_error_stack(e)

            return

    def exec_javascript_click(self, element):
        return self._exec_driver_action(self.driver, 'execute_script', "arguments[0].click();", element)

    def exec_set_attribute(self, element, attributeName, value):
        self.exec_driver_action(
            self.driver, 'execute_script', "arguments[0].setAttribute(arguments[1],arguments[2])",
            element, attributeName, value
        )

    def exec_add_attribute(self, element, attributeName, value):
        """
            封装向页面标签添加新属性的方法
            调用JS给页面标签添加新属性，arguments[0]~arguments[2]分别
            会用后面的element，attributeName和value参数进行替换
            添加新属性的JS代码语法为：element.attributeName=value
            比如input.name='test'
        """
        self.exec_driver_action(
            self.driver, 'execute_script', "arguments[0].%s=arguments[1]" % attributeName, element, value
        )

    def exec_remove_attribute(self, element, attributeName):
        """
            封装删除页面属性的方法
            调用JS代码删除页面元素的指定的属性，arguments[0]~arguments[1]分别
            会用后面的element，attributeName参数进行替换
        """
        self.exec_driver_action(
            self.driver, 'execute_script', "arguments[0].removeAttribute(arguments[1])", element, attributeName
        )

    def _init_db(self, conn=False):
        if getattr(self, 'db_engine') is None:
            self.db_engine = create_engine(getattr(self, 'conn_str'), pool_size=1, max_overflow=0, pool_recycle=3600, pool_pre_ping=True)
            self.table.create(self.db_engine, checkfirst=True)

        if conn is True:
            return self.db_engine.connect()

        return self.db_engine

    def conn_execute(self, sql, ignore_errors=False, data=None, convert_to_list=True):
        conn = self._init_db(True)

        retries = 0
        while True:
            try:
                if data:
                    res = conn.execute(sql, **data)
                else:
                    res = conn.execute(sql)

                if convert_to_list:
                    res = list(res)
            except ResourceClosedError:
                return
            except OperationalError as e:
                if ignore_errors:
                    return
                else:
                    retries += 1
                    if retries < 5:
                        self.sleep(1)
                        continue
                    else:
                        raise e
            except SQLAlchemyError as e:
                if ignore_errors:
                    return
                else:
                    raise e
            else:
                return res
            finally:
                if conn:
                    conn.close()

    def engine_execute(self, stmt, ignore_errors=False, convert_to_list=True):
        engine = self._init_db()

        retries = 0
        while True:
            try:
                res = engine.execute(stmt)
                if convert_to_list:
                    res = list(res)
            except ResourceClosedError:
                return
            except OperationalError as e:
                if ignore_errors:
                    return
                else:
                    retries += 1
                    if retries < 5:
                        self.sleep(1)
                        continue
                    else:
                        raise e
            except SQLAlchemyError as e:
                if ignore_errors:
                    return
                else:
                    raise e
            else:
                return res

    def _init_request(self, method='GET', is_async=False, *args, **kwargs):
        if is_async is True:
            return AsyncHTMLSession()

        session = HTMLSession()
        response = session.request(method, *args, **kwargs)
        response.raise_for_status()
        return response

    def execute_crawl_request(self, filename, obj):
        settings = self.read_config(filename)
        CrawlRules = settings['CrawlRules']

        for rule in CrawlRules:
            print("CrawlRules: %s" % rule)
            Target, Response, Database = rule["Target"], rule["Response"], rule["Database"]

            if "hook" in Target['kwargs']:
                hook = Target['kwargs']['hook']
                Target['kwargs'] = getattr(obj, hook)()

            resp = self._init_request(Target['method'].upper(), Target['session'], url=Target['url'], **Target['kwargs'])

            if Response is not None:
                cleaning, html = getattr(obj, Response["cleaning"]), getattr(resp, Response["get"])
                ML.warning("The 'html' object type is: %s" % type(html))
                C = cleaning(html, Response["rule"])
                # TODO: C 是数据清理函数完成的结果集，接下来就是对结果集存放到数据库中

            if Response is not None:
                ...

    # download image
    def execute_download_image(self, url, filename, filepath="./download_image/"):
        os.makedirs(filepath, exist_ok=True)

        from urllib.request import urlretrieve
        urlretrieve(url, os.path.join(filepath, filename))

    def switch_page(self, to=-1, wait=None):
        """
        对 driver 对象切换窗口，第一个页面是0，以此类推
        @param wait: 切换页面等待的渲染时间
        @param to: 到达页面
        @return:
        """
        windows = self.get_windows
        if wait:
            self.sleep(wait)

        self.driver.switch_to.window(windows[to])

    def close_driver_page(self, inxs):
        inxs.reverse()
        for clos in inxs:
            self.switch_page(clos)
            self.exec_driver_action(self.driver, 'close')

        return self.switch_page(wait=1)

    def into_judge(self, locator: tuple, timeout=20):
        try:
            element = WebDriverWait(self.driver, timeout=timeout, poll_frequency=0.1).until(
                ec.presence_of_element_located(locator)
            )
            return element
        except TimeoutException:
            return False

    @property
    def driver_add_cookie(self):
        try:
            with open('DrivingRecord/config/cookie.json', 'r', encoding='utf-8') as f:
                istCookies = json.loads(f.read())
                for cookie in istCookies:
                    self.driver.add_cookie(cookie)
        except Exception as e:
            ML.warning('add cookie is fail: %s' % str(e))
            return False
        return True

    def driver_add_str_cookie(self, cookie_str):
        """
        对 driver 对象添加 cookie
        @param cookie_str: cookie字符串
        """
        self.driver.delete_all_cookies()
        cookies = cookie_str.split(';')
        for cookie in cookies:
            cookie_dict = {}.fromkeys(('name', 'value'))
            cookie_dict['name'] = cookie.split('=')[0].strip()
            cookie_dict['value'] = cookie.split('=')[1].strip()
            self.driver.add_cookie(cookie_dict)

    def driver_add_dict_cookie(self, cookie_dict):
        """
        对 driver 对象添加cookie
        @param cookie_dict: cookie字典
        """
        self.driver.delete_all_cookies()
        for cookie_key, cookie_value in cookie_dict.items():
            self.driver.add_cookie({'name': cookie_key, 'value': cookie_value})

    @staticmethod
    def read_config(config_path) -> dict:
        with open(config_path, 'r', encoding='utf-8') as ym:
            return yaml.load(ym, Loader=yaml.Loader)

    @staticmethod
    def make_config(mapName: str, mapConfig: dict):
        with open(mapName, 'w', encoding='utf-8') as ym:
            yaml.dump(mapConfig, ym, allow_unicode=True, Dumper=yaml.RoundTripDumper)

        ML.info("Profile created successfully: <%s>" % mapName)

    def get_settings_field(self, key, verif=False):
        if self.ym.get(key) is None and verif is False:
            raise ValueError(
                "Check if there is a '%s' field in the global configuration file, it may be that the field has no value set, it must have a key and a value" %
                key
            )

        return self.ym.get(key)

    @property
    def get_now_time(self):
        return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())

    @property
    def get_windows(self) -> list[str]:
        return self.driver.window_handles

    @staticmethod
    def position_action(x, y, action):
        return getattr(pyautogui, action)(x=x, y=y)

    @property
    def get_running_start(self):
        start = time.perf_counter()
        return start

    @staticmethod
    def get_running_end(start):
        end = time.perf_counter()
        return end - start
